winbrew_core\fs\archive\extract/
zip.rs

1use std::fs;
2use std::io::{Read, Write};
3use std::path::Path;
4
5use crate::fs::{FsError, Result};
6
7use super::super::context::ExtractionContext;
8use super::super::limits::ExtractionLimits;
9use super::super::platform::PlatformAdapter;
10
11pub(crate) fn extract_zip_archive_with_platform<P: PlatformAdapter>(
12    zip_path: &Path,
13    destination_dir: &Path,
14    limits: ExtractionLimits,
15) -> Result<()> {
16    let file = fs::File::open(zip_path).map_err(|err| FsError::open_zip_archive(zip_path, err))?;
17    let mut archive =
18        zip::ZipArchive::new(file).map_err(|err| FsError::open_zip_archive(zip_path, err))?;
19    const ZIP_COPY_BUFFER_SIZE: usize = 256 * 1024;
20    let mut extraction = ExtractionContext::<P>::new(limits);
21    let mut buffer = vec![0u8; ZIP_COPY_BUFFER_SIZE];
22
23    for index in 0..archive.len() {
24        let mut entry = archive
25            .by_index(index)
26            .map_err(|err| FsError::read_zip_entry(zip_path, err))?;
27        extract_entry(&mut entry, destination_dir, &mut extraction, &mut buffer)?;
28    }
29
30    extraction.commit();
31    Ok(())
32}
33
34fn extract_entry<P: PlatformAdapter, R: Read>(
35    entry: &mut zip::read::ZipFile<'_, R>,
36    destination_dir: &Path,
37    extraction: &mut ExtractionContext<P>,
38    buffer: &mut [u8],
39) -> Result<()> {
40    let enclosed_name = entry
41        .enclosed_name()
42        .ok_or_else(FsError::invalid_zip_entry_path)?;
43
44    if entry.is_symlink() {
45        return Err(FsError::symlink_entry(
46            &destination_dir.join(&enclosed_name),
47        ));
48    }
49
50    let outpath = destination_dir.join(&enclosed_name);
51
52    extraction.validate_target(&outpath, destination_dir)?;
53
54    extraction.check_limits(&enclosed_name, entry.size(), entry.compressed_size())?;
55
56    if entry.is_dir() {
57        extraction.ensure_directory_tree(&outpath)?;
58        return Ok(());
59    }
60
61    if let Some(parent) = outpath.parent() {
62        extraction.ensure_directory_tree(parent)?;
63    }
64
65    let mut outfile = P::create_extraction_target_file(&outpath)
66        .map_err(|err| FsError::create_extracted_file(&outpath, err))?;
67    extraction.record_file(&outpath);
68
69    loop {
70        let bytes_read = entry
71            .read(buffer)
72            .map_err(|err| FsError::read_entry(&outpath, err))?;
73        if bytes_read == 0 {
74            break;
75        }
76
77        outfile
78            .write_all(&buffer[..bytes_read])
79            .map_err(|err| FsError::write_entry(&outpath, err))?;
80    }
81
82    Ok(())
83}
84
85#[cfg(test)]
86mod tests {
87    use super::super::extract_zip_archive_with_limits;
88    use super::*;
89    use crate::fs::archive::extract_zip_archive;
90    use std::fs;
91    use std::io::Write;
92    use tempfile::tempdir;
93    use zip::ZipWriter;
94    use zip::write::SimpleFileOptions;
95
96    fn create_zip_archive(path: &std::path::Path, file_name: &str, contents: &[u8]) {
97        let file = fs::File::create(path).expect("create zip file");
98        let mut writer = ZipWriter::new(file);
99        writer
100            .start_file(file_name, SimpleFileOptions::default())
101            .expect("start zip entry");
102        writer.write_all(contents).expect("write zip contents");
103        writer.finish().expect("finish zip file");
104    }
105
106    fn create_symlink_archive(path: &std::path::Path, link_name: &str, target: &str) {
107        let file = fs::File::create(path).expect("create zip file");
108        let mut writer = ZipWriter::new(file);
109        writer
110            .add_symlink(link_name, target, SimpleFileOptions::default())
111            .expect("add zip symlink");
112        writer.finish().expect("finish zip file");
113    }
114
115    fn create_archive_with_entries(path: &std::path::Path, entries: &[(&str, &[u8])]) {
116        let file = fs::File::create(path).expect("create zip file");
117        let mut writer = ZipWriter::new(file);
118
119        for (name, contents) in entries {
120            writer
121                .start_file(name, SimpleFileOptions::default())
122                .expect("start zip entry");
123            writer.write_all(contents).expect("write zip contents");
124        }
125
126        writer.finish().expect("finish zip file");
127    }
128
129    #[test]
130    #[cfg(windows)]
131    fn extract_zip_archive_rejects_hardlinked_targets() {
132        let temp_dir = tempdir().expect("temp dir");
133        let destination_dir = temp_dir.path().join("dest");
134        let anchor_path = temp_dir.path().join("anchor.txt");
135        let target_path = destination_dir.join("payload.txt");
136        let zip_path = temp_dir.path().join("archive.zip");
137
138        fs::create_dir_all(&destination_dir).expect("destination dir");
139        fs::write(&anchor_path, b"anchor").expect("anchor file");
140        fs::hard_link(&anchor_path, &target_path).expect("hard link");
141        create_zip_archive(&zip_path, "payload.txt", b"zip payload");
142
143        let error = extract_zip_archive(&zip_path, &destination_dir)
144            .expect_err("expected hardlinked target rejection");
145
146        assert!(error.to_string().contains("hardlinked file"));
147    }
148
149    #[test]
150    fn extract_zip_archive_rejects_symlink_entries() {
151        let temp_dir = tempdir().expect("temp dir");
152        let destination_dir = temp_dir.path().join("dest");
153        let zip_path = temp_dir.path().join("archive.zip");
154
155        fs::create_dir_all(&destination_dir).expect("destination dir");
156        create_symlink_archive(&zip_path, "bin/tool.exe", "target.exe");
157
158        let error = extract_zip_archive(&zip_path, &destination_dir)
159            .expect_err("expected symlink rejection");
160
161        assert!(
162            error
163                .to_string()
164                .contains("refusing to extract symlink entry")
165        );
166        assert!(!destination_dir.join("bin").exists());
167    }
168
169    #[test]
170    fn extract_zip_archive_cleans_partial_output_on_failure() {
171        let temp_dir = tempdir().expect("temp dir");
172        let destination_dir = temp_dir.path().join("dest");
173        let zip_path = temp_dir.path().join("archive.zip");
174
175        let file = fs::File::create(&zip_path).expect("create zip file");
176        let mut writer = ZipWriter::new(file);
177        writer
178            .start_file("bin/ok.txt", SimpleFileOptions::default())
179            .expect("start ok entry");
180        writer.write_all(b"ok").expect("write ok entry");
181        writer
182            .add_symlink("bin/bad-link", "target.exe", SimpleFileOptions::default())
183            .expect("add symlink entry");
184        writer.finish().expect("finish zip file");
185
186        let error = extract_zip_archive(&zip_path, &destination_dir)
187            .expect_err("expected cleanup after partial extraction failure");
188
189        assert!(
190            error
191                .to_string()
192                .contains("refusing to extract symlink entry")
193        );
194        assert!(!destination_dir.exists());
195    }
196
197    #[test]
198    fn extract_zip_archive_extracts_files_correctly() {
199        let temp_dir = tempdir().expect("temp dir");
200        let destination_dir = temp_dir.path().join("dest");
201        let zip_path = temp_dir.path().join("archive.zip");
202
203        fs::create_dir_all(&destination_dir).expect("dest dir");
204        create_zip_archive(&zip_path, "bin/tool.exe", b"binary content");
205
206        extract_zip_archive(&zip_path, &destination_dir).expect("extraction");
207
208        assert_eq!(
209            fs::read(destination_dir.join("bin/tool.exe")).expect("read"),
210            b"binary content"
211        );
212    }
213
214    #[test]
215    fn extract_zip_archive_rejects_existing_target_files() {
216        let temp_dir = tempdir().expect("temp dir");
217        let destination_dir = temp_dir.path().join("dest");
218        let existing_target = destination_dir.join("bin/tool.exe");
219        let zip_path = temp_dir.path().join("archive.zip");
220
221        fs::create_dir_all(existing_target.parent().expect("parent dir")).expect("destination dir");
222        fs::write(&existing_target, b"existing content").expect("preexisting target");
223        create_zip_archive(&zip_path, "bin/tool.exe", b"new content");
224
225        let error = extract_zip_archive(&zip_path, &destination_dir)
226            .expect_err("expected overwrite protection");
227
228        assert!(
229            error
230                .to_string()
231                .contains("failed to create extracted file")
232        );
233        assert_eq!(
234            fs::read(&existing_target).expect("read preexisting target"),
235            b"existing content"
236        );
237    }
238
239    #[test]
240    fn extract_zip_archive_cleans_deeply_nested_partial_output() {
241        let temp_dir = tempdir().expect("temp dir");
242        let destination_dir = temp_dir.path().join("dest");
243        let zip_path = temp_dir.path().join("archive.zip");
244
245        fs::create_dir_all(&destination_dir).expect("destination dir");
246
247        let file = fs::File::create(&zip_path).expect("create zip file");
248        let mut writer = ZipWriter::new(file);
249        writer
250            .start_file("a/b/c/d/file.txt", SimpleFileOptions::default())
251            .expect("start file entry");
252        writer.write_all(b"payload").expect("write payload");
253        writer
254            .add_symlink(
255                "a/b/c/d/bad-link",
256                "target.exe",
257                SimpleFileOptions::default(),
258            )
259            .expect("add symlink entry");
260        writer.finish().expect("finish zip file");
261
262        let error = extract_zip_archive(&zip_path, &destination_dir)
263            .expect_err("expected cleanup after nested failure");
264
265        assert!(
266            error
267                .to_string()
268                .contains("refusing to extract symlink entry")
269        );
270        assert!(destination_dir.exists());
271        assert!(!destination_dir.join("a").exists());
272    }
273
274    #[test]
275    fn extract_zip_archive_rejects_suspicious_compression_ratio() {
276        let temp_dir = tempdir().expect("temp dir");
277        let destination_dir = temp_dir.path().join("dest");
278        let zip_path = temp_dir.path().join("archive.zip");
279
280        fs::create_dir_all(&destination_dir).expect("destination dir");
281        create_zip_archive(&zip_path, "payload.txt", b"compressible payload");
282
283        let error = extract_zip_archive_with_limits(
284            &zip_path,
285            &destination_dir,
286            ExtractionLimits {
287                max_total_size: 10 * 1024 * 1024 * 1024,
288                max_file_count: 100_000,
289                max_compression_ratio: 0,
290                max_path_depth: 255,
291            },
292        )
293        .expect_err("expected suspicious compression ratio rejection");
294
295        assert!(error.to_string().contains("suspicious compression ratio"));
296        assert!(!destination_dir.join("payload.txt").exists());
297    }
298
299    #[test]
300    fn extract_zip_archive_rejects_total_size_limit() {
301        let temp_dir = tempdir().expect("temp dir");
302        let destination_dir = temp_dir.path().join("dest");
303        let zip_path = temp_dir.path().join("archive.zip");
304
305        fs::create_dir_all(&destination_dir).expect("destination dir");
306        create_zip_archive(&zip_path, "payload.txt", b"abcd");
307
308        let error = extract_zip_archive_with_limits(
309            &zip_path,
310            &destination_dir,
311            ExtractionLimits {
312                max_total_size: 3,
313                max_file_count: 100_000,
314                max_compression_ratio: 100,
315                max_path_depth: 255,
316            },
317        )
318        .expect_err("expected quota rejection");
319
320        assert!(error.to_string().contains("quota exceeded"));
321        assert!(!destination_dir.join("payload.txt").exists());
322    }
323
324    #[test]
325    fn extract_zip_archive_rejects_file_count_limit() {
326        let temp_dir = tempdir().expect("temp dir");
327        let destination_dir = temp_dir.path().join("dest");
328        let zip_path = temp_dir.path().join("archive.zip");
329
330        fs::create_dir_all(&destination_dir).expect("destination dir");
331        create_archive_with_entries(&zip_path, &[("first.txt", b""), ("second.txt", b"")]);
332
333        let error = extract_zip_archive_with_limits(
334            &zip_path,
335            &destination_dir,
336            ExtractionLimits {
337                max_total_size: 10 * 1024 * 1024 * 1024,
338                max_file_count: 1,
339                max_compression_ratio: 100,
340                max_path_depth: 255,
341            },
342        )
343        .expect_err("expected file count rejection");
344
345        assert!(error.to_string().contains("entry count exceeded"));
346        assert!(!destination_dir.join("first.txt").exists());
347    }
348
349    #[test]
350    fn extract_zip_archive_rejects_path_depth_limit() {
351        let temp_dir = tempdir().expect("temp dir");
352        let destination_dir = temp_dir.path().join("dest");
353        let zip_path = temp_dir.path().join("archive.zip");
354
355        fs::create_dir_all(&destination_dir).expect("destination dir");
356        create_zip_archive(&zip_path, "a/b/c/file.txt", b"payload");
357
358        let error = extract_zip_archive_with_limits(
359            &zip_path,
360            &destination_dir,
361            ExtractionLimits {
362                max_total_size: 10 * 1024 * 1024 * 1024,
363                max_file_count: 100_000,
364                max_compression_ratio: 100,
365                max_path_depth: 2,
366            },
367        )
368        .expect_err("expected path depth rejection");
369
370        assert!(error.to_string().contains("too deep"));
371        assert!(!destination_dir.join("a").exists());
372    }
373}